Skip to content

pgconn: drain socket in asyncClose to avoid RST on context cancel#2585

Merged
jackc merged 1 commit into
jackc:masterfrom
sean-:worktree-asyncclose-drain
Jun 20, 2026
Merged

pgconn: drain socket in asyncClose to avoid RST on context cancel#2585
jackc merged 1 commit into
jackc:masterfrom
sean-:worktree-asyncclose-drain

Conversation

@sean-

@sean- sean- commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

When a context is canceled while the server is mid-stream, asyncClose() sends CancelRequest, writes Terminate, and closes the underlying net.Conn. At that point the kernel receive buffer typically still holds DataRow messages that were already in flight before the cancel landed. Closing a TCP socket with unread data in the receive queue causes the kernel to discard it and send RST instead of FIN (RFC 1122 §4.2.2.13). The peer (PostgreSQL, or a proxy such as pgdog or PgBouncer) sees this as connection reset by peer and logs it as an error even though the disconnect was intentional.

This change adds io.Copy(io.Discard, pgConn.conn) after the Terminate flush in asyncClose(), draining whatever the server already sent. In the normal case the server stops producing rows shortly after CancelRequest, processes Terminate, and closes its side; the drain returns on io.EOF and the deferred Close() emits a clean FIN. The existing 15-second deadline bounds the drain so a misbehaving server cannot block cleanup indefinitely.

Behavior change

asyncClose() previously returned from its goroutine immediately after writing Terminate. It now blocks (in that background goroutine) until the server closes or the 15s deadline fires. Callers that wait on CleanupDone() (e.g. connection pools) may see slightly delayed slot reclamation against an unresponsive server. Against a healthy server the drain completes in one RTT plus whatever was already buffered.

Testing

TestAsyncCloseDrainsBeforeClose spins up a mock backend that floods the client with ~16 MB of DataRows, has the client cancel mid-stream, and asserts the server observes Terminate followed by io.EOF. Without the fix the test fails with the exact error from the issue report:

    read tcp 127.0.0.1:...: read: connection reset by peer

With the fix it passes in ~100ms and is -race clean over repeated runs.

AI disclosure

This change was drafted with assistance from Claude Code (Anthropic). I have reviewed the diff line by line, understand why closing with a non-empty receive buffer produces RST, why draining to io.Discard resolves it, and why the existing deadline is the correct bound. I can answer questions about the change directly.

Fixes #2584

When a context is canceled while the server is still streaming rows,
asyncClose() sends CancelRequest, writes Terminate, and closes the
underlying net.Conn. At that point the kernel receive buffer typically
still holds DataRow messages that were already in flight before the
cancel landed. Closing a TCP socket with unread data causes the kernel
to discard the receive queue and send RST instead of FIN (RFC 1122
4.2.2.13), which surfaces on the server or proxy as "connection reset
by peer".

Drain the socket with io.Copy(io.Discard, conn) after sending
Terminate. The existing 15s deadline bounds the read; in the normal
case the server stops producing rows after CancelRequest, processes
Terminate, and closes its side, so the drain returns on io.EOF and the
deferred Close() emits a clean FIN.

Add TestAsyncCloseDrainsBeforeClose, which floods a mock client with
DataRows, cancels mid-stream, and asserts the server observes Terminate
followed by io.EOF rather than ECONNRESET. The test reproduces the
reported "connection reset by peer" without the fix.

Fixes jackc#2584

Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
@jackc jackc merged commit 387c560 into jackc:master Jun 20, 2026
7 of 14 checks passed
@sean- sean- deleted the worktree-asyncclose-drain branch June 21, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Context cancellation mid-scan causes TCP RST on server side (stdlib driver)

2 participants